⚛ React state batch updating 이슈

React에서 render에 영향을 주는 setState를 통해 state에 대한 업데이트 요청은 비동기로 전달된다.

또한, React는 상태값을 업데이트 할 때 모든 요청에 따라 바로바로 rerender가 되는것이 아닌 변경사항을 모아서 한번에 일괄 처리한다고 한다.

모든 요청에 각각 반응하여 rerender를 하는것보단, 단 한번의 rerender를 통해 성능 향상을 위함이라고 한다.

React공문 에서는 setState에 대해 이런식으로 표현했다.

상태 업데이트는 비동기식일 수 있습니다. React는 setState()성능을 위해 여러 호출을 단일 업데이트로 일괄 처리할 수 있습니다 .

import './styles.css'
import React from 'react'

export default function App() {
  const [state, setState] = React.useState(0)
  const [state2, setState2] = React.useState(0)

  React.useEffect(() => {
    for (let i = 0; i < 10; i++) {
      setState(i)
      setState2(i)
    }
  }, [])

  React.useEffect(() => {
    for (let i = 10; i < 20; i++) {
      setState(i)
      setState2(i)
    }
  }, [])

  console.log(state, state2)

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
    </div>
  )
}

// console
// 0 0
// 19 19

이전 class에서 동일한 이벤트 내에서 여러번의 setState는 병합의 방식으로, hook을 사용한 setState는 대체의 방식으로 진행된다.

예외적인 상황

intersectionObserver는 사용자의 스크롤이 어느 지점을 넘어섰을 때 발생하는 이벤트이다.

기존 스크롤 이벤트는 여러번 이벤트가 호출 될 수 있다는 점에서 throttling을 함께 사용했었는데, 이를 대신해주어서 infinite scroll을 구현할 때 사용하곤 한다.

intersectonObserver의 조건에 도달했을 때, 아래와 같은 함수가 호출되도록 작성을 하였다.

실제로 이런 코드는 아니였고.. 간단하게 수정하였다.

const [state, setState] = useState(0)

const handleGetData = useCallback(() => {
  console.log('setState 1')
  setState(1)
  console.log('setState 2')
  setState(2)
}, [])

console.log(state)

가장 아래에 작성된 consolererender 작동 여부를 파악하고자 작성을 했는데 예상하지 못한 결과를 보였다.

setState를 통한 요청은 비동기로 전달되기 때문에, 아래와 같은 결과를 반환한다고 생각했다.

// 'setState 1'
// 'setState 2'
// 2

하지만 작동을 하였을 때 실제로 보인 결과는 이러했다.

// 'setState 1'
// 1
// 'setState 2'
// 2

각각의 setState요청들이 비동기로 전달되어 batch로 병합을 통해 단 한번의 rerender만 발생하는것이 아니라, 각각의 setState요청에 대해 변경되는 상태값에 대해 바로바로 rerender가 발생했다.

비슷한 결과를 보였던 예제가 한가지 더 있다.

const [testState, setTestState] = useState(0)
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)

const async = useCallback(async () => {
  setLoading(true)
  setTestState(1)
  try {
    const data = await (function() {
      return new Promise(res => {
        setTimeout(() => {
          res('done')
        })
      })
    })()
    setData(data)
    setLoading(false)
  } catch (error) {
    setError(error)
    setLoading(false)
  }
}, [])

useEffect(() => {
  async()
}, [async])

console.log(testState, data, loading)

console.log(testState, data, loading)

React-QueryRedux-Saga를 사용하지 않고, React Hook만을 사용하여 비동기작업을 처리하고자 할 때 이런식으로 작성할 것이다.

위의 결과를 미리 보자면

// 0 null false
// 1 null true
// 1 "done" true
// 1 "done" false

setLoading(false)setTestState(1)은 비동기로 전달이 되고, 한번의 rerender를 위해 잘 병합되어 상태값이 업데이트 되었다.

하지만, await이후의 setState들은 각각의 요청에 별도의 rerender를 갖고있다.

위의 두가지 상황에 있어서는 공통점을 하나 갖고있다.

state batch updating의 예외상황

  1. intersectionObserver
  2. Promise.then

intersectionObserverelement가 보이는지 안보이는지에 따라 비동기로 이벤트를 처리해준다.

awaitPromise.then으로 대표적인 비동기 작업이다.

async/await, then/catch, setTimeout, fetch와 같은 비동기 동작을 사용하는 핸들러 내부의 setState들은 batch작업이 이뤄지지 않는다고 한다.

따라서, await이전의 setState들은 batch가 정상적으로 진행되었지만, .then이후의 콜백함수에 해당되는 setState들은 비동기작업과 관련된 핸들러 내부에 위치하고 있기 때문에 batch가 작동되지 않았던것 같다.

React18

React의 18버전에는 모든 경우에 batching 에 대해 많은 이슈가 있는것 같다.

React 18 adds automatic batching for all use cases to improve performance even further. Now, React batches state updates in React events handlers, promises, setTimeout, native event handlers and so on.

느낌

setState가 작동되는 방식에 있어서 생각보다 많은부분에서 성능을 고려해주는것 같다.

사실, 위의 2번째 상황과 같은 비동기작업 처리에 있어서는 여러개의 상태값이 변화될수 있다는 점이나, 에러핸들링과 같이 로직이 길어질 수 있다는 점을 고려해보았을 때 useReducer를 사용하는것이 더 적합해보이기도 한다.

SUCCESS, FAIL라는 액션으로 분리하고, 그에 맞는 복수의 상태값변경 및 로직 작성

최근 ReactReact Hook등의 작동원리같은 디테일한 부분에 호기심이 생겨서 더 알아보는 중이다.

적어도 내가 사용하는 프레임워크, 모듈에 대해서는 깊은 이해를 갖고 있어야겠다는 생각을 하고 있다.

참조


@SangMin
👆 H'e'story

🚀GitHub